Skip to content

fix(ios): re-apply layer.transform after super.invalidateLayer#41

Merged
janicduplessis merged 1 commit intoAppAndFlow:mainfrom
mbesirovic:fix/invalidate-layer-transform
May 5, 2026
Merged

fix(ios): re-apply layer.transform after super.invalidateLayer#41
janicduplessis merged 1 commit intoAppAndFlow:mainfrom
mbesirovic:fix/invalidate-layer-transform

Conversation

@mbesirovic
Copy link
Copy Markdown
Contributor

Summary

invalidateLayer re-applies opacity, cornerRadius, backgroundColor, border, and shadow after [super invalidateLayer] clobbers them with identity values from the View's (transform-stripped) style — but it misses transform. Result: any transform-animated view that receives an invalidateLayer call mid-CAAnimation snaps back to identity the moment the animation completes.

This PR adds the missing transform re-apply in invalidateLayer.

The bug

The override at the top of -[EaseView invalidateLayer] covers most layer properties that super resets, but transform is missing. Since the JS shim's cleanStyle logic strips style.transform whenever animate includes any transform component (translateX/scaleX/rotate/etc.), the View has no transform style for super to use — it resets layer.transform to identity and the override never restores it.

For views that aren't transform-animating, this is invisible. For transform-animated views, the bug surfaces only in the narrow window where:

  1. updateProps' "subsequent updates" branch sets layer.transform = targetTransform and queues a CABasicAnimation with fromValue=current, toValue=target, fillMode=removed.
  2. Before the animation completes, a sibling re-render / parent layout / FlashList layout settle / etc. fires invalidateLayer on this view. [super invalidateLayer] resets layer.transform = identity.
  3. The override re-applies other animated values but not transform, so the model layer stays at identity.
  4. The CAAnimation finishes. With default fillMode=removed, the running animation is removed and presentation reverts to model, which is now identity. The view snaps to its un-transformed pose.

If no invalidateLayer happens to fire during the animation, you never see the bug. If one does, you see it.

A clean reproduction: hero ↔ list morphing cards inside a FlashList with LayoutAnimation.configureNext driving the row height transition. As the row heights compress, the layout settle triggers invalidateLayer on cells whose vertical position is shifting — exactly mid-flight of the transform animation. After the morph completes, those specific cells (typically not the topmost ones) revert to identity transform. Backgrounding and reopening the app "fixes" the affected cells because foregrounding triggers a window-wide displayIfNeeded after the animation has long since completed; with no animation in flight, the existing branches in invalidateLayer correct the model on whatever subset of properties they touch — and once a normal updateProps cycle runs again, the transform path corrects too. (This is the user-facing tell that something in the post-animation pipeline isn't quite right.)

The fix

// In invalidateLayer's apply block, before the per-property branches:
if (hasTransform) {
  self.layer.transform = [self targetTransformFromProps:viewProps];
}

Plus kMaskAnyTransform added to the early-return gate so transform-only animated views (e.g. animate={{ translateX, translateY }} without an animated borderRadius/opacity etc.) still reach the apply block.

Why the re-apply must NOT be gated on !isAnimating

I tried gating this on "no transform animation currently in flight" first, on the intuition that we shouldn't yank a running animation back to its target. That reasoning is wrong, and gating breaks the fix:

  • A running CABasicAnimation interpolates the presentation layer using its own fromValue/toValue and ignores the model layer for its lifetime.
  • We're already inside [CATransaction setDisableActions:YES], so the model write does NOT start an implicit animation.
  • The model layer needs to be at the target value at the moment the explicit animation completes, because fillMode=removed reverts the presentation to model on completion. If we skip the re-apply during an in-flight animation (which is exactly when the bug needs the fix!), the model stays at identity and the snap-back is preserved.

So the re-apply happens unconditionally. During an in-flight animation, the visual is unaffected (presentation is animation-driven). After the animation removes itself, the now-correct model takes over. No yank, no flicker.

Diff

19 insertions, 3 deletions, single file (ios/EaseView.mm).

Verification

  • RN 0.85.1, Fabric, iOS 17 simulator (iPhone 16 Pro): morph reproduces the bug pre-fix; clean post-fix.
  • iOS 17 physical device (iPhone): same.
  • All non-transform animated views unchanged in behavior (the existing branches still run the same way).

Happy to adjust the comment wording or split into smaller commits if preferred.

`-[RCTViewComponentView invalidateLayer]` re-applies the View's style
layer-properties to the underlying CALayer — including transform. Since
EaseView's JS shim strips `style.transform` whenever `animate` includes
any transform component, super resets `layer.transform = identity` on
every invalidateLayer call. The override here re-applied opacity,
cornerRadius, backgroundColor, border, and shadow — but missed transform.

For static or already-settled views this was invisible. Views in the
middle of a transform CAAnimation hit it as a hard-to-reproduce visual
bug:

  1. updateProps' "subsequent updates" branch sets `layer.transform =
     targetTransform` and queues a CABasicAnimation with
     `fromValue=current, toValue=target, fillMode=removed`.
  2. Mid-animation, a sibling re-render or layout settle triggers
     `invalidateLayer` on this view. `[super invalidateLayer]` resets
     `layer.transform` to identity from the empty style.transform.
  3. The override re-applies other animated properties but leaves the
     model layer's transform at identity.
  4. The CAAnimation finishes. With `fillMode=removed`, presentation
     reverts to model — which is now identity. The view snaps to its
     un-transformed position/scale.

Backgrounding and reopening the app worked around the bug because
foregrounding triggers a window-wide displayIfNeeded after the
animation has long since completed; at that point the override's
re-apply branch executes with no in-flight animation and the model can
be corrected.

This was first surfaced in a hero ↔ list morphing card with FlashList +
LayoutAnimation, where mid-flight invalidateLayer calls during the
layout settle were the trigger.

The fix:

* Add `kMaskAnyTransform` to the early-return gate so transform-only
  animated views still hit the re-apply block.
* Re-apply `self.layer.transform = [self targetTransformFromProps:...]`
  unconditionally inside the existing `setDisableActions:YES`
  CATransaction.

The re-apply is intentionally NOT gated on "no animation in flight". A
running CABasicAnimation interpolates the *presentation* layer between
its own fromValue/toValue and ignores the model layer for its lifetime.
With actions disabled, writing to the model does not start an implicit
animation. The model needs to be at target so that when the explicit
animation removes itself (fillMode=removed), presentation reverts to the
correct resting state — not identity.

Verified on RN 0.85.1 / Fabric / iOS 17 sim and iPhone (real device).
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes an iOS/Fabric rendering bug in EaseView where -[super invalidateLayer] resets CALayer.transform to identity mid-animation, causing transform-animated views to snap back to identity when the explicit CAAnimation completes.

Changes:

  • Extend invalidateLayer’s early-return mask gate to include kMaskAnyTransform.
  • Re-apply self.layer.transform from the component’s animated props inside the CATransaction (with actions disabled).
  • Add detailed inline rationale explaining why the transform re-apply must occur even while an explicit animation is in flight.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Copy link
Copy Markdown
Collaborator

@janicduplessis janicduplessis left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the fix!

@janicduplessis janicduplessis merged commit 87827cb into AppAndFlow:main May 5, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants